--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 0f5034642f21e3ad4f4a71dfe0cee8e5b0f0ee5c
Parents : 6843d03
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-16T23:32:13-05:00
feat(tests): add loopback TCP fixture for integration tests and apply it to relevant test cases
Changes
15 files changed, 244 insertions(+), 15 deletions(-)
Diff
diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 7a81b776..e09e0c3e 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -2,6 +2,7 @@
import asyncio
import os
+import socket
import tempfile
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
@@ -20,6 +21,35 @@ from meshchatx.src.backend.database.schema import DatabaseSchema
os.environ["MESHCHAT_LOG_DIR"] = tempfile.mkdtemp()
+@pytest.fixture(scope="session")
+def loopback_available():
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ accepted = None
+ try:
+ server.settimeout(0.5)
+ client.settimeout(0.5)
+ server.bind(("127.0.0.1", 0))
+ server.listen(1)
+ port = server.getsockname()[1]
+ client.connect(("127.0.0.1", port))
+ accepted, _ = server.accept()
+ return True
+ except OSError:
+ return False
+ finally:
+ if accepted is not None:
+ accepted.close()
+ client.close()
+ server.close()
+
+
+@pytest.fixture
+def require_loopback_tcp(loopback_available):
+ if not loopback_available:
+ pytest.skip("Loopback TCP is blocked by local firewall/policy; skipping localhost integration test.")
+
+
@pytest.fixture(autouse=True)
def global_mocks():
with (
diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py
index 0b2b3a07..8b76559e 100644
--- a/tests/backend/test_access_attempts_enforcement.py
+++ b/tests/backend/test_access_attempts_enforcement.py
@@ -240,6 +240,7 @@ def _make_aio_app(mock_app, use_https: bool):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_login_records_access_attempt_and_debug_list(mock_app):
mock_app.config.auth_enabled.set(True)
pw = b"smoke-access-attempt-pw-ok"
@@ -267,6 +268,7 @@ async def test_login_records_access_attempt_and_debug_list(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_lockout_login_returns_429_smoke(mock_app):
mock_app.config.auth_enabled.set(True)
pw = b"lockout-smoke-pw"
@@ -297,6 +299,7 @@ async def test_lockout_login_returns_429_smoke(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_rate_limited_login_returns_429_smoke(mock_app):
mock_app.config.auth_enabled.set(True)
pw = b"rl-smoke-pw"
@@ -346,6 +349,7 @@ async def test_rate_limited_login_returns_429_smoke(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_debug_access_attempts_endpoint_returns_shape(mock_app):
mock_app.config.auth_enabled.set(False)
aio_app = _make_aio_app(mock_app, use_https=False)
diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py
index 09fafc56..de27e253 100644
--- a/tests/backend/test_bot_handler_extended.py
+++ b/tests/backend/test_bot_handler_extended.py
@@ -111,7 +111,9 @@ def test_get_status_reads_sidecar_lxmf_address(temp_identity_dir):
storage = os.path.join(handler.bots_dir, sid)
os.makedirs(storage, exist_ok=True)
hx = "a" * 32
- with open(os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8") as f:
+ with open(
+ os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8"
+ ) as f:
f.write(hx)
handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}]
status = handler.get_status()
@@ -176,7 +178,9 @@ def test_request_announce_writes_trigger(mock_alive, temp_identity_dir):
sid = "b1"
storage = os.path.join(handler.bots_dir, sid)
os.makedirs(storage, exist_ok=True)
- handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}]
+ handler.bots_state = [
+ {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}
+ ]
handler.request_announce(sid)
req = os.path.join(storage, "meshchatx_request_announce")
assert os.path.isfile(req)
@@ -189,6 +193,8 @@ def test_request_announce_not_running(temp_identity_dir):
sid = "b1"
storage = os.path.join(handler.bots_dir, sid)
os.makedirs(storage, exist_ok=True)
- handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}]
+ handler.bots_state = [
+ {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}
+ ]
with pytest.raises(RuntimeError, match="not running"):
handler.request_announce(sid)
diff --git a/tests/backend/test_http_auth_security.py b/tests/backend/test_http_auth_security.py
index 08374a2c..1f9deda9 100644
--- a/tests/backend/test_http_auth_security.py
+++ b/tests/backend/test_http_auth_security.py
@@ -52,6 +52,7 @@ def test_encrypted_cookie_storage_http_flags(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_login_sets_cookie_and_allows_protected_api(mock_app):
mock_app.config.auth_enabled.set(True)
pw = b"integration-test-password-ok"
@@ -79,6 +80,7 @@ async def test_login_sets_cookie_and_allows_protected_api(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_ws_returns_401_without_session_when_auth_enabled(mock_app):
mock_app.config.auth_enabled.set(True)
mock_app.config.auth_password_hash.set(
@@ -92,6 +94,7 @@ async def test_ws_returns_401_without_session_when_auth_enabled(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_logout_clears_session_for_protected_api(mock_app):
mock_app.config.auth_enabled.set(True)
pw = b"logout-test-password-ok"
@@ -111,6 +114,7 @@ async def test_logout_clears_session_for_protected_api(mock_app):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_auth_login_invalid_json_returns_400(mock_app):
mock_app.config.auth_enabled.set(True)
mock_app.config.auth_password_hash.set(
@@ -135,6 +139,7 @@ async def test_auth_login_invalid_json_returns_400(mock_app):
deadline=None,
)
@given(body=st.binary(min_size=0, max_size=12000))
+@pytest.mark.usefixtures("require_loopback_tcp")
def test_auth_login_fuzz_never_500(mock_app, body):
mock_app.config.auth_enabled.set(True)
mock_app.config.auth_password_hash.set(
diff --git a/tests/backend/test_https_wss_side_sniffing.py b/tests/backend/test_https_wss_side_sniffing.py
index c1707038..7e7c196c 100644
--- a/tests/backend/test_https_wss_side_sniffing.py
+++ b/tests/backend/test_https_wss_side_sniffing.py
@@ -17,6 +17,8 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
+pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
+
def _make_self_signed_cert_and_key(cert_path: str, key_path: str) -> None:
private_key = rsa.generate_private_key(
diff --git a/tests/backend/test_lxmf_propagation_sync_integration.py b/tests/backend/test_lxmf_propagation_sync_integration.py
index 01c9f1cc..f702e4f9 100644
--- a/tests/backend/test_lxmf_propagation_sync_integration.py
+++ b/tests/backend/test_lxmf_propagation_sync_integration.py
@@ -104,12 +104,16 @@ def integration_app(temp_dir):
def _route_handler(app, path, method="GET"):
- return next(r.handler for r in app.get_routes() if r.path == path and r.method == method)
+ return next(
+ r.handler for r in app.get_routes() if r.path == path and r.method == method
+ )
@pytest.mark.asyncio
@pytest.mark.integration
-async def test_remote_propagation_sync_transitions_path_requested_to_complete(integration_app):
+async def test_remote_propagation_sync_transitions_path_requested_to_complete(
+ integration_app,
+):
app, fake_router = integration_app
remote_hash = b"\x22" * 16
fake_router.set_outbound_propagation_node(remote_hash)
@@ -131,18 +135,24 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in
with (
patch("meshchatx.meshchat.RNS.Transport.has_path", side_effect=has_path),
- patch("meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path),
+ patch(
+ "meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path
+ ),
):
first_sync = await sync_handler(None)
assert first_sync.status == 200
- first_status = json.loads((await status_handler(None)).body)["propagation_node_status"]
+ first_status = json.loads((await status_handler(None)).body)[
+ "propagation_node_status"
+ ]
assert first_status["state"] == "path_requested"
second_sync = await sync_handler(None)
assert second_sync.status == 200
- second_status = json.loads((await status_handler(None)).body)["propagation_node_status"]
+ second_status = json.loads((await status_handler(None)).body)[
+ "propagation_node_status"
+ ]
assert second_status["state"] == "complete"
assert second_status["progress"] == 100.0
assert fake_router.request_messages_calls >= 2
@@ -150,7 +160,9 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in
@pytest.mark.asyncio
@pytest.mark.integration
-async def test_local_preferred_propagation_sync_completes_without_remote_lookup(integration_app):
+async def test_local_preferred_propagation_sync_completes_without_remote_lookup(
+ integration_app,
+):
app, fake_router = integration_app
local_hash = fake_router.propagation_destination.hash
fake_router.set_outbound_propagation_node(local_hash)
@@ -165,7 +177,9 @@ async def test_local_preferred_propagation_sync_completes_without_remote_lookup(
response = await sync_handler(None)
assert response.status == 200
- status_data = json.loads((await status_handler(None)).body)["propagation_node_status"]
+ status_data = json.loads((await status_handler(None)).body)[
+ "propagation_node_status"
+ ]
assert status_data["state"] == "complete"
assert status_data["progress"] == 100.0
assert fake_router.request_messages_calls == 0
diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py
index 56477adf..11f32bf7 100644
--- a/tests/backend/test_notifications.py
+++ b/tests/backend/test_notifications.py
@@ -445,6 +445,7 @@ def test_random_notification_operations(db, operations):
@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
async def test_auth_middleware_returns_401_for_api_without_session_when_auth_enabled(
mock_app,
):
diff --git a/tests/frontend/BotsPage.test.js b/tests/frontend/BotsPage.test.js
index 16cfb7fe..c15dc4f3 100644
--- a/tests/frontend/BotsPage.test.js
+++ b/tests/frontend/BotsPage.test.js
@@ -77,7 +77,7 @@ describe("BotsPage.vue", () => {
const cards = wrapper.findAll("div.cursor-pointer");
const templateCard = cards.filter(
- (d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages"),
+ (d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages")
)[0];
await templateCard.trigger("click");
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 21d2bbb5..ee26b445 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import ConversationViewer from "@/components/messages/ConversationViewer.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
import GlobalState from "@/js/GlobalState";
+import DialogUtils from "@/js/DialogUtils";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -17,6 +18,7 @@ describe("ConversationViewer.vue", () => {
GlobalState.config.theme = "light";
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
+ GlobalState.config.warn_on_stranger_links = true;
WebSocketConnection.connect();
axiosMock = {
get: vi.fn().mockImplementation((url) => {
@@ -39,6 +41,7 @@ describe("ConversationViewer.vue", () => {
// Mock URL.createObjectURL
window.URL.createObjectURL = vi.fn(() => "mock-url");
+ vi.spyOn(window, "open").mockImplementation(() => null);
// Mock FileReader
const mockFileReader = {
@@ -57,6 +60,9 @@ describe("ConversationViewer.vue", () => {
afterEach(() => {
delete window.api;
+ if (window.open?.mockRestore) {
+ window.open.mockRestore();
+ }
vi.unstubAllGlobals();
WebSocketConnection.destroy();
});
@@ -173,6 +179,80 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.composerImageDropActive).toBe(false);
});
+ it("warns before opening http links from strangers when enabled", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.isStrangerPeer = true;
+ GlobalState.config.warn_on_stranger_links = true;
+ DialogUtils.confirm.mockClear();
+ window.open.mockClear();
+ DialogUtils.confirm.mockResolvedValueOnce(true);
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "https://example.com/path");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm");
+ expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
+ });
+
+ it("does not open stranger http link when warning confirm is rejected", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.isStrangerPeer = true;
+ GlobalState.config.warn_on_stranger_links = true;
+ DialogUtils.confirm.mockClear();
+ window.open.mockClear();
+ DialogUtils.confirm.mockResolvedValueOnce(false);
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "https://example.com/path");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(DialogUtils.confirm).toHaveBeenCalled();
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
+ it("opens stranger http link without prompt when warning is disabled", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.isStrangerPeer = true;
+ GlobalState.config.warn_on_stranger_links = false;
+ DialogUtils.confirm.mockClear();
+ window.open.mockClear();
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "https://example.com/path");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(DialogUtils.confirm).not.toHaveBeenCalled();
+ expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
+ });
+
+ it("blocks non-http href payloads like data urls in message anchors", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.isStrangerPeer = true;
+ GlobalState.config.warn_on_stranger_links = true;
+ DialogUtils.confirm.mockClear();
+ window.open.mockClear();
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "data:image/png;base64,AAAA");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(DialogUtils.confirm).not.toHaveBeenCalled();
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
it("onComposerImageDrop ignores non-image files", () => {
const wrapper = mountConversationViewer();
const pdf = new File([""], "doc.pdf", { type: "application/pdf" });
diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js
index 75a2383b..4fa23821 100644
--- a/tests/frontend/LinkUtils.test.js
+++ b/tests/frontend/LinkUtils.test.js
@@ -126,6 +126,13 @@ describe("LinkUtils.js", () => {
expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i);
});
+ it("does not produce data: href for data image payload text", () => {
+ const text = "inline image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA";
+ const result = LinkUtils.renderStandardLinks(text);
+ expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i);
+ expect(result).not.toContain("<a ");
+ });
+
it("stops URL at space so no script in same line", () => {
const text = "https://example.com javascript:alert(1)";
const result = LinkUtils.renderStandardLinks(text);
diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index 038212b4..559022c5 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -49,8 +49,7 @@ describe("MarkdownRenderer.js", () => {
});
it("keeps underscores intact in long https links", () => {
- const url =
- "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
+ const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
const result = MarkdownRenderer.render(`visit ${url}`);
expect(result).toContain(`href="${url}"`);
expect(result).toContain(url);
@@ -390,8 +389,7 @@ describe("MarkdownRenderer.js", () => {
});
it("strip keeps intraword underscores intact", () => {
- const url =
- "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
+ const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
expect(MarkdownRenderer.strip(url)).toBe(url);
});
diff --git a/tests/frontend/MessagesSidebar.test.js b/tests/frontend/MessagesSidebar.test.js
index 0cebbbca..56aabd40 100644
--- a/tests/frontend/MessagesSidebar.test.js
+++ b/tests/frontend/MessagesSidebar.test.js
@@ -171,6 +171,16 @@ describe("MessagesSidebar UI", () => {
expect(conversationsPanel.exists()).toBe(true);
});
+ it("uses right-edge collapse icons when sidebar position is right", () => {
+ const left = mountSidebar({ sidebarPosition: "left" });
+ expect(left.vm.expandedTabBarChevronIcon).toBe("chevron-left");
+ expect(left.vm.collapsedStripChevronIcon).toBe("chevron-right");
+
+ const right = mountSidebar({ sidebarPosition: "right" });
+ expect(right.vm.expandedTabBarChevronIcon).toBe("chevron-right");
+ expect(right.vm.collapsedStripChevronIcon).toBe("chevron-left");
+ });
+
it("emits conversation-click when a conversation row is clicked", async () => {
const conversations = [
{
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index 66de4396..6e3f5f21 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -137,6 +137,13 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_glass_enabled: false });
});
+ it("onMessagesSidebarPositionChange PATCHes messages_sidebar_position", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.messages_sidebar_position = "right";
+ await w.vm.onMessagesSidebarPositionChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { messages_sidebar_position: "right" });
+ });
+
it("resetAppearanceDefaults PATCHes full appearance payload", async () => {
const w = await mountSettingsPage(api);
await w.vm.resetAppearanceDefaults();
@@ -144,6 +151,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
"/api/v1/config",
expect.objectContaining({
theme: "light",
+ messages_sidebar_position: "left",
message_font_size: 14,
message_icon_size: 28,
ui_transparency: 0,
@@ -347,6 +355,8 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_all_from_strangers: true });
await w.vm.onShowUnknownContactBannerChange(false);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { show_unknown_contact_banner: false });
+ await w.vm.onWarnOnStrangerLinksChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { warn_on_stranger_links: false });
});
it("banishment PATCHes toggle and debounced text/color", async () => {
diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js
index 0c16d73c..02377acc 100644
--- a/tests/frontend/fixtures/settingsPageTestApi.js
+++ b/tests/frontend/fixtures/settingsPageTestApi.js
@@ -75,10 +75,12 @@ export function buildFullServerConfig(overrides = {}) {
block_attachments_from_strangers: true,
block_all_from_strangers: false,
show_unknown_contact_banner: true,
+ warn_on_stranger_links: true,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
message_font_size: 14,
+ messages_sidebar_position: "left",
message_icon_size: 28,
ui_transparency: 0,
ui_glass_enabled: true,
diff --git a/tests/frontend/loadingStatusNotice.test.js b/tests/frontend/loadingStatusNotice.test.js
new file mode 100644
index 00000000..70e45efe
--- /dev/null
+++ b/tests/frontend/loadingStatusNotice.test.js
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { createRequire } from "module";
+
+const require = createRequire(import.meta.url);
+const { classifyConnectionIssue, classifyFetchError } = require("../../electron/loadingStatusNotice.js");
+
+describe("loadingStatusNotice", () => {
+ it("classifies backend exits before connection succeeds", () => {
+ const issue = classifyConnectionIssue([], {
+ running: false,
+ lastExitCode: 1,
+ });
+ expect(issue.reason).toBe("backend-exited");
+ expect(issue.headline).toContain("stopped");
+ });
+
+ it("classifies address-unreachable network errors as loopback blocks", () => {
+ const issue = classifyConnectionIssue([{ kind: "address-unreachable" }, { kind: "network-error" }]);
+ expect(issue.reason).toBe("loopback-blocked");
+ expect(issue.detail).toContain("firewall");
+ });
+
+ it("classifies HTTP 5xx failures as backend-side startup errors", () => {
+ const issue = classifyConnectionIssue([{ kind: "http-error", status: 503 }]);
+ expect(issue.reason).toBe("backend-http-error");
+ expect(issue.headline).toContain("internal error");
+ });
+
+ it("delays generic network-blocked warnings while backend may still be starting", () => {
+ const issue = classifyConnectionIssue([{ kind: "network-error" }], null, {
+ attemptCount: 10,
+ networkWarnAfterAttempts: 24,
+ });
+ expect(issue.reason).toBe("starting");
+ });
+
+ it("shows generic network-blocked warning after startup grace period", () => {
+ const issue = classifyConnectionIssue([{ kind: "network-error" }], null, {
+ attemptCount: 30,
+ networkWarnAfterAttempts: 24,
+ });
+ expect(issue.reason).toBe("network-blocked");
+ expect(issue.detail).toContain("firewall");
+ });
+
+ it("classifies invalid payload responses as backend response issues", () => {
+ const issue = classifyConnectionIssue([{ kind: "invalid-payload" }]);
+ expect(issue.reason).toBe("backend-invalid-response");
+ });
+
+ it("parses ERR_ADDRESS_UNREACHABLE fetch errors", () => {
+ const kind = classifyFetchError(new TypeError("net::ERR_ADDRESS_UNREACHABLE"));
+ expect(kind).toBe("address-unreachable");
+ });
+
+ it("treats other fetch failures as generic network errors", () => {
+ const kind = classifyFetchError(new TypeError("Failed to fetch"));
+ expect(kind).toBe("network-error");
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────